2026_justice_stress.py

#

SPDX-FileCopyrightText: 2026 Jonathan Waerzeggers SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

import math  # On importe le module pour faire des calculs mathématiques (comme le sinus)
import random  # On importe le module pour générer des nombres aléatoires

import bpy  # On importe le module pour contrôler l'interface et les objets de Blender
import mathutils  # On importe les outils pour manipuler les positions et vecteurs 3D
#
#######################################################################

VARIABLES DE CONFIGURATION ET ALLÉATOIRES

la_taille = 10  # Taille de base pour la création des deux blocs cubes
div = 5  # Nombre de coupures pour diviser les faces et pouvoir les déformer proprement
hasard = random.randint(
    -2, 2
)  # Nombre aléatoire entre -2 et 2 pour modifier la forme du sol (socle)
hasard_plafond = random.randint(
    -2, 2
)  # Nombre aléatoire entre -2 et 2 pour modifier la forme du toit (plafond)

if (
    hasard_plafond == hasard
):  # Si les deux nombres aléatoires sont identiques, on force le plafond à changer
    hasard_plafond = (
        -hasard if hasard != 0 else 2
    )  # Le plafond prend l'opposé du sol, ou prend la valeur 2 si le sol vaut zéro

liste_des_z = []  # Création d'une liste vide pour enregistrer des hauteurs précalculées

for i in range(
    6
):  # On crée une boucle qui va tourner 6 fois pour calculer des vagues de base
    liste_des_z.append(
        math.sin((2 * 3.14159 / 6) * i)
    )  # On calcule une valeur de sinus régulière et on l'ajoute dans notre liste
#
#######################################################################

FONCTIONS DE CALCUL DU RELIEF DES VAGUES

#
def le_calcul_du_z_socle(
    x, y
):  # Définition de la formule mathématique qui donne sa forme au sol (le socle)
    mon_z = 0.0  # On commence avec une hauteur de déformation égale à zéro
    if x < 1:  # Si le point est situé à gauche (coordonnée X plus petite que 1)
        mon_z = mon_z + 2.0  # On monte le niveau du sol de 2 unités de hauteur

    if (
        x > -3 and x < 3
    ):  # Si le point se trouve dans la zone centrale (X compris entre -3 et 3)
        if (
            y > -3 and y < 3
        ):  # Et si le point est aussi centré sur l'axe de la profondeur (Y entre -3 et 3)
            index = int(
                (x + 3) % 6
            )  # On calcule un index d'onde cyclique en fonction de la position X
            mon_z = (
                mon_z + (liste_des_z[index] * hasard)
            )  # On ajoute l'ondulation de notre table de sinus multipliée par le hasard du sol

    if (
        x > 2
    ):  # Si le point est situé à l'extrême droite (coordonnée X plus grande que 2)
        mon_z = mon_z - (
            (x - 2) * 0.4
        )  # On crée une pente douce qui descend vers la droite

    return mon_z  # On renvoie la hauteur finale calculée pour ce point précis du sol
#
def le_calcul_du_z_plafond(
    x, y
):  # Définition de la formule mathématique qui donne sa forme au toit (le plafond)
    mon_z = 0.0  # On commence avec une hauteur de déformation égale à zéro
    if x > 1:  # Si le point est situé à droite (coordonnée X plus grande que 1)
        mon_z += 1.8  # On ajoute une épaisseur vers le bas de 1.8 unités
    if (
        -3.5 < x < 3.5 and -3.5 < y < 3.5
    ):  # Si le point est confiné au centre du modèle (X et Y entre -3.5 et 3.5)
        idx_principale = int(
            (x + 4) % 6
        )  # On calcule un index d'onde cyclique adapté pour le plafond
        onde_principale = (
            liste_des_z[idx_principale] * hasard_plafond * 1.3
        )  # On calcule une grosse ondulation sur l'axe X selon le hasard du plafond
        onde_secondaire = (
            math.sin(y * 1.5) * 0.7
        )  # On croise cette ondulation avec une deuxième vague sur l'axe Y
        micro_bruit = (
            math.cos(x * 2.5) * 0.3
        )  # On rajoute des micro-bosses très serrées pour faire un effet rugueux
        mon_z += (
            onde_principale + onde_secondaire + micro_bruit
        )  # On additionne toutes ces ondes ensemble pour sculpter l'intérieur
    if x < -1:  # Si le point est situé à gauche (coordonnée X plus petite que -1)
        mon_z -= (
            abs(x) - 1
        ) * 0.35  # On fait plonger la forme du plafond vers le bas sur le côté gauche
    return mon_z  # On renvoie la hauteur finale calculée pour ce point précis du toit
#
#######################################################################

FONCTIONS DE GÉNÉRATION DES MAILLAGES

#
def creation_socle():  # Fonction qui va fabriquer et déformer le bloc du bas
    bpy.ops.mesh.primitive_cube_add(
        size=la_taille, location=(0, 0, 0)
    )  # On ajoute un cube parfait au centre de la scène 3D
    bloc = (
        bpy.context.active_object
    )  # On récupère ce cube pour pouvoir le modifier dans le script
    bloc.name = "Socle"  # On lui donne le nom de "Socle" dans notre projet

    bloc.scale[2] = (
        0.4  # On l'écrase sur l'axe vertical Z pour en faire une dalle plate
    )
    bpy.ops.object.transform_apply(
        location=False, rotation=False, scale=True
    )  # On applique l'échelle pour valider la nouvelle taille de l'objet

    bpy.ops.object.mode_set(
        mode="EDIT"
    )  # On passe en mode Édition pour avoir accès à la géométrie de l'objet
    bpy.ops.mesh.subdivide(
        number_cuts=div
    )  # On subdivise les faces du cube pour ajouter de nombreux petits carrés à déformer
    bpy.ops.object.mode_set(
        mode="OBJECT"
    )  # On revient en mode Objet pour appliquer les calculs sur les points

    for le_point in (
        bloc.data.vertices
    ):  # On passe en revue un par un tous les sommets (points) du socle
        xx = le_point.co.x  # On lit sa position sur l'axe X
        yy = le_point.co.y  # On les sa position sur l'axe Y
        deformation = le_calcul_du_z_socle(
            xx, yy
        )  # On calcule la déformation grâce à notre formule mathématique du sol

        if (
            le_point.co.z < 0
        ):  # Si le point est situé sous la ligne du milieu (la face du dessous du cube)
            le_point.co.z = (
                -la_taille / 2.0
            )  # On le force à rester parfaitement plat pour que le dessous reste droit
        else:  # Si le point est situé sur la face du dessus
            le_point.co.z = (
                -3.5 + deformation
            )  # On modifie sa hauteur Z en appliquant la déformation calculée

    lissage = bloc.modifiers.new(
        name="Lissage", type="SUBSURF"
    )  # On crée un modificateur pour lisser la surface du socle
    lissage.levels = (
        4  # On règle le niveau de lissage à 4 pour avoir une belle courbe bien propre
    )
    bpy.ops.object.shade_smooth()  # On active le lissage visuel des ombres pour effacer l'aspect facetté
#
def creation_plafond():  # Fonction qui va fabriquer et déformer le bloc du haut
    bpy.ops.mesh.primitive_cube_add(
        size=la_taille, location=(0, 0, 0)
    )  # On ajoute un cube parfait au centre de la scène 3D
    bloc = bpy.context.active_object  # On sélectionne ce nouveau cube
    bloc.name = "Plafond"  # On le nomme "Plafond" dans la liste des objets

    bloc.scale[2] = (
        0.4  # On l'écrase verticalement sur l'axe Z pour faire une dalle fine
    )
    bpy.ops.object.transform_apply(
        location=False, rotation=False, scale=True
    )  # On valide cette modification d'échelle

    bpy.ops.object.mode_set(mode="EDIT")  # On passe en mode Édition
    bpy.ops.mesh.subdivide(
        number_cuts=div
    )  # On subdivise l'objet pour générer un maillage détaillé
    bpy.ops.object.mode_set(mode="OBJECT")  # On repasse en mode Objet

    for le_point in bloc.data.vertices:  # On analyse tous les sommets du bloc supérieur
        xx = le_point.co.x  # On lit la position X du sommet
        yy = -le_point.co.y  # On inverse la position Y pour créer un effet de symétrie inversée avec le bas
        deformation = le_calcul_du_z_plafond(
            xx, yy
        )  # On calcule la déformation du toit avec notre formule mathématique du plafond

        if (
            le_point.co.z > 0
        ):  # Si le point est situé tout en haut (le toit extérieur du modèle)
            le_point.co.z = la_taille / 2.0  # On le force à rester aligné et plat
        else:  # Si le point est situé en dessous (l'intérieur de notre grotte)
            le_point.co.z = (
                1.5 - deformation
            )  # On épaissit le bloc à 1.5 et on applique le relief vers

    lissage = bloc.modifiers.new(
        name="Lissage", type="SUBSURF"
    )  # On ajoute le modificateur de subdivision de surface pour lisser la voûte
    lissage.levels = 4  # On pousse la qualité géométrique à 4
    bpy.ops.object.shade_smooth()  # On active l'ombrage lissé pour adoucir le rendu visuel
#
#######################################################################

FONCTIONS DE DISTRIBUTION DES PIQUES (LANCER DE RAYONS)

#
def creation_piques():  # Fonction qui va peupler l'espace vide avec des stalactites et stalagmites
    bpy.context.view_layer.update()  # On demande à Blender de rafraîchir l'emplacement exact de la géométrie des blocs
    mon_graphe = bpy.context.evaluated_depsgraph_get()  # On récupère la carte physique finale et réelle de nos objets (graphe de dépendance)

    i = 0  # On initialise un compteur à zéro pour gérer notre boucle
    while i < 120:  # On lance une boucle pour faire 120 essais de création de piques
        choix_zone = random.choice(
            ["bord_gauche", "inter_gauche", "inter_droite", "bord_droit"]
        )  # On tire au sort une zone pour éviter de mettre des piques là où il y aura les trous

        if (
            choix_zone == "bord_gauche"
        ):  # Répartition des coordonnées X selon la zone tirée au sort
            pos_x = random.uniform(-4.3, -3.3)
        elif choix_zone == "inter_gauche":
            pos_x = random.uniform(-1.7, -0.8)
        elif choix_zone == "inter_droite":
            pos_x = random.uniform(0.8, 1.7)
        else:
            pos_x = random.uniform(3.3, 4.3)

        pos_y = random.uniform(
            -3.5, 3.5
        )  # On choisit une coordonnée Y au hasard sur l'axe de la profondeur
        origine = mathutils.Vector(
            (pos_x, pos_y, 0.0)
        )  # On définit un point de départ virtuel en plein centre de la grotte (Z = 0.0)

        v_bas = mathutils.Vector(
            (0.0, 0.0, -1.0)
        )  # On prépare un vecteur directionnel vertical qui pointe vers le bas
        v_haut = mathutils.Vector(
            (0.0, 0.0, 1.0)
        )  # On prépare un vecteur directionnel vertical qui pointe vers le haut

        touche_bas, loc1, n1, i1, obj1, m1 = bpy.context.scene.ray_cast(
            mon_graphe, origine, v_bas
        )  # On jette un rayon laser vers le bas pour détecter l'impact sur le sol (loc1)
        touche_haut, loc2, n2, i2, obj2, m2 = bpy.context.scene.ray_cast(
            mon_graphe, origine, v_haut
        )  # On jette un rayon laser vers le haut pour détecter l'impact sur le plafond (loc2)

        if (
            touche_bas == True and touche_haut == True
        ):  # Si nos deux lasers ont bien trouvé un obstacle solide en haut et en bas
            hauteur_totale = (
                loc2.z - loc1.z
            )  # On calcule l'espace de vide total disponible entre nos deux vagues
            largeur = random.uniform(
                0.3, 0.6
            )  # On choisit un diamètre aléatoire pour la base de notre future pique
            chance = random.random()  # On tire un nombre au sort entre 0.0 et 1.0 pour décider de la nature de la pique

            if chance < 0.30:  # Si le nombre est plus petit que 0.30 (30% de chances)
                h_pilier = (
                    hauteur_totale + 0.5
                )  # CAS 1 : On crée un grand pilier porteur qui relie le bas et le haut
                bpy.ops.mesh.primitive_cone_add(
                    vertices=3, radius1=largeur, depth=h_pilier, location=loc1
                )  # On génère un cône à 3 côtés posé à l'emplacement du sol (loc1)
                le_pilier = bpy.context.active_object  # On attrape ce nouveau cône
                le_pilier.location.z = (
                    loc1.z + (h_pilier / 2.0) - 0.5
                )  # On décale sa hauteur pour que son socle plat s'encastre parfaitement dans le sol
                le_pilier.rotation_euler = (
                    0,
                    0,
                    random.uniform(0, 6.28),
                )  # On lui applique une rotation aléatoire sur l'axe Z pour qu'il soit orienté de biais

            elif chance > 0.80:  # Si le nombre est plus grand que 0.80 (20% de chances)
                h_stalactite = (
                    hauteur_totale * random.uniform(0.15, 0.40)
                ) + 0.5  # CAS 2 : On crée une stalactite suspendue au plafond
                bpy.ops.mesh.primitive_cone_add(
                    vertices=3, radius1=largeur, depth=h_stalactite, location=loc2
                )  # On ajoute un cône à 3 côtés à l'emplacement du plafond (loc2)
                la_stalactite = bpy.context.active_object  # On attrape cette stalactite
                la_stalactite.location.z = (
                    loc2.z - (h_stalactite / 2.0) + 0.5
                )  # On l'ajuste en hauteur pour qu'elle s'enracine proprement dans le bloc du haut
                la_stalactite.rotation_euler = (
                    math.pi,
                    0,
                    random.uniform(0, 6.28),
                )  # On la retourne de 180 degrés (math.pi) sur l'axe X pour qu'elle pointe vers le bas

            else:  # Dans tous les autres cas (50% de chances restantes)
                h_spike = (
                    (hauteur_totale * random.uniform(0.1, 0.45)) + 0.5
                )  # CAS 3 : On crée une stalagmite classique qui monte depuis le sol
                bpy.ops.mesh.primitive_cone_add(
                    vertices=3, radius1=largeur, depth=h_spike, location=loc1
                )  # On crée le cône sur le sol (loc1)
                le_spike = bpy.context.active_object  # On attrape la stalagmite
                le_spike.location.z = (
                    loc1.z + (h_spike / 2.0) - 0.5
                )  # On cale sa base plate sur le maillage de notre socle déformé
                le_spike.rotation_euler = (
                    0,
                    0,
                    random.uniform(0, 6.28),
                )  # On lui donne un angle de rotation aléatoire sur l'axe vertical

        i = i + 1  # On ajoute 1 au compteur pour valider notre itération et continuer
#
#######################################################################

FONCTIONS DE DÉCOUPE DES TROUS (OPÉRATIONS BOOLÉENNES)

#
def faire_les_trous_du_plafond():  # Fonction qui va venir creuser le plafond avec des gabarits cubes
    le_plafond = bpy.data.objects.get(
        "Plafond"
    )  # On récupère notre objet principal nommé "Plafond"
    if (
        le_plafond == None
    ):  # Si par malheur le plafond n'est pas trouvé dans la scène, on annule tout
        return  # On quitte la fonction pour éviter de générer un message d'erreur

    mes_trous = []  # On prépare une liste vide pour mettre nos blocs emporte-pièces

    bpy.ops.mesh.primitive_cube_add(
        size=1.0, location=(-2.5, 0.0, 5.0)
    )  # Fabrication du gabarit de découpe pour le côté gauche (X = -2.5)
    t1 = bpy.context.active_object  # On attrape ce premier cube outil
    t1.scale = (
        1.2,
        3.5,
        15.0,
    )  # On l'étire pour en faire un grand pavé rectangulaire capable de traverser le plafond
    bpy.ops.object.transform_apply(
        scale=True
    )  # On valide ses dimensions dans l'espace 3D
    mes_trous.append(t1)  # On l'ajoute à notre liste d'outils

    bpy.ops.mesh.primitive_cube_add(
        size=1.0, location=(0.0, 0.0, 6.0)
    )  # Fabrication du gabarit de découpe pour le centre (X = 0.0)
    t2 = bpy.context.active_object  # On attrape ce deuxième cube outil
    t2.scale = (
        1.2,
        3.5,
        20.0,
    )  # On lui donne des proportions allongées (encore plus haut sur Z pour assurer le coup)
    bpy.ops.object.transform_apply(scale=True)  # On applique les transformations
    mes_trous.append(t2)  # On l'ajoute à la liste

    bpy.ops.mesh.primitive_cube_add(
        size=1.0, location=(2.5, 0.0, 5.0)
    )  # Fabrication du gabarit de découpe pour le côté droit (X = 2.5)
    t3 = bpy.context.active_object  # On sélectionne le troisième cube outil
    t3.scale = (
        1.2,
        3.5,
        15.0,
    )  # On l'ajuste aux mêmes échelles que le premier rectangle de gauche
    bpy.ops.object.transform_apply(scale=True)  # On applique les transformations
    mes_trous.append(t3)  # On range ce dernier outil dans la liste

    bpy.ops.object.select_all(
        action="DESELECT"
    )  # On désélectionne tous les objets de l'interface Blender

    for (
        truc
    ) in mes_trous:  # On fait une boucle sur nos trois objets gabarits rectangulaires
        truc.select_set(True)  # On les sélectionne tous ensemble

    bpy.context.view_layer.objects.active = mes_trous[
        0
    ]  # On choisit le premier rectangle de la liste comme l'élément de référence actif
    bpy.ops.object.join()  # On fusionne les 3 rectangles en un seul et unique gros maillage outil (Join)
    le_gros_trou = (
        bpy.context.active_object
    )  # On associe notre variable à cet objet combiné final

    le_booleen = le_plafond.modifiers.new(
        name="Coupe", type="BOOLEAN"
    )  # On ajoute un modificateur de géométrie booléenne sur notre objet Plafond
    le_booleen.operation = "DIFFERENCE"  # On règle le modificateur sur le mode DIFFERENCE (Soustraction de matière)
    le_booleen.object = le_gros_trou  # On lui donne la cible : c'est notre gros objet outil à trois blocs qui va creuser
    le_booleen.solver = "EXACT"  # On sélectionne le solveur EXACT pour avoir des bords parfaits sur les trous

    bpy.context.view_layer.objects.active = le_plafond  # On remet le Plafond comme l'objet actif pour pouvoir valider l'opération
    bpy.ops.object.modifier_apply(
        modifier="Coupe"
    )  # On valide et on applique définitivement le trou dans la géométrie du plafond

    bpy.data.objects.remove(
        le_gros_trou, do_unlink=True
    )  # On supprime définitivement l'objet outil de la scène pour ne pas encombrer Blender
#
#######################################################################

NETTOYAGE GÉNÉRAL DE LA SCÈNE

#
def nettoie_tout():  # Fonction qui nettoie les anciens essais avant de lancer une nouvelle génération
    bpy.ops.object.select_all(
        action="SELECT"
    )  # On ordonne la sélection de la totalité des objets de la scène
    try:  # On ouvre une sécurité pour éviter les plantages si la caméra n'existe pas
        bpy.data.objects["Oblique Camera"].select_set(
            False
        )  # On retire la caméra fixe nommée "Oblique Camera" de notre sélection pour ne pas la perdre
    except:  # Si Blender ne trouve pas cette caméra, on ignore simplement le problème
        pass  # On passe à l'étape suivante sans bloquer le script

    bpy.ops.object.delete()  # On efface tous les objets sélectionnés (les anciens socles, plafonds et cônes)
    bpy.ops.outliner.orphans_purge()  # On efface les blocs de données inutilisés dans la mémoire cache de Blender
#
#######################################################################

CORPS PRINCIPAL DU PROGRAMME (LANCEMENT)

nettoie_tout()  # Étape 1 : On efface les anciens objets de la scène 3D
creation_socle()  # Étape 2 : On génère le socle du bas et on déforme sa surface
creation_plafond()  # Étape 3 : On génère le plafond épais et volumineux
creation_piques()  # Étape 4 : On peuple l'espace de la caverne en distribuant nos pointes cônes
faire_les_trous_du_plafond()  # Étape 5 : En dernier, on applique la soustraction booléenne pour percer les fentes du toit